feat: implement cascading deletion for related records in delete #488
feat: implement cascading deletion for related records in delete #488
Conversation
There was a problem hiding this comment.
Pull request overview
This PR adds application-level cascading deletion support for related records in AdminForth's delete endpoint. When onDelete: 'cascade' or onDelete: 'setNull' is configured on a foreignResource column, deleting a parent record will either recursively delete child records or null-out the foreign key column in child records. The PR also adds detection of database-level ON DELETE CASCADE constraints in SQLite, PostgreSQL, and MySQL, with warnings when such constraints exist (to avoid conflicts with the new application-level cascade logic).
Changes:
- New optional
onDeletefield ('cascade' | 'setNull') added toAdminForthForeignResourcetype, with validation inconfigValidator.ts - Core cascade deletion/null-out logic added to the
/delete_recordREST endpoint inrestApi.ts - Database-level
ON DELETE CASCADEdetection and warning added to all three data connectors (SQLite, PostgreSQL, MySQL)
Reviewed changes
Copilot reviewed 6 out of 6 changed files in this pull request and generated 6 comments.
Show a summary per file
| File | Description |
|---|---|
adminforth/types/Back.ts |
Adds onDelete field to AdminForthForeignResource interface |
adminforth/modules/restApi.ts |
Implements cascade delete / setNull logic before deleting the parent record |
adminforth/modules/configValidator.ts |
Validates the onDelete strategy value |
adminforth/dataConnectors/sqlite.ts |
Detects DB-level ON DELETE CASCADE via PRAGMA foreign_key_list |
adminforth/dataConnectors/postgres.ts |
Detects DB-level ON DELETE CASCADE via pg_constraint system tables |
adminforth/dataConnectors/mysql.ts |
Detects DB-level ON DELETE CASCADE via information_schema |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 6 out of 6 changed files in this pull request and generated 6 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
adminforth/modules/restApi.ts
Outdated
| await this.adminforth.resource(childRes.resourceId).delete(childRecord[childPkFieldName]); | ||
| } | ||
| } else if (onDeleteStrategy === 'setNull') { | ||
| for (const childRecord of childRecords) { | ||
| await this.adminforth.resource(childRes.resourceId).update(childRecord[childPkFieldName], {[foreignResourceColumn.name]: null}); |
There was a problem hiding this comment.
The cascade deletion at line 1496 uses this.adminforth.resource(childRes.resourceId).delete(childRecord[childPkFieldName]), which calls OperationalResource.delete() → connector.deleteRecord() directly, completely bypassing:
- The resource's
deletehooks (hooks.delete.beforeSaveandhooks.delete.afterSave) - Permission checks (
allowedActions.delete) for the child resource
This is a critical concern because these hooks are often used for cleanup of related artifacts (e.g., file storage cleanup in upload plugins, as referenced by the warning in the connector code). Additionally, the setNull path at line 1500 has the same issue — this.adminforth.resource(childRes.resourceId).update() similarly bypasses edit hooks for child records.
To trigger hooks and permissions, the implementation should use this.adminforth.deleteResourceRecord(...) for cascade deletions and this.adminforth.updateResourceRecord(...) for setNull operations, similar to how the parent record deletion is handled at line 1506.
| await this.adminforth.resource(childRes.resourceId).delete(childRecord[childPkFieldName]); | |
| } | |
| } else if (onDeleteStrategy === 'setNull') { | |
| for (const childRecord of childRecords) { | |
| await this.adminforth.resource(childRes.resourceId).update(childRecord[childPkFieldName], {[foreignResourceColumn.name]: null}); | |
| const { error: childDeleteError } = await this.adminforth.deleteResourceRecord({ | |
| resource: childRes, | |
| record: childRecord, | |
| adminUser, | |
| recordId: childRecord[childPkFieldName], | |
| response, | |
| extra: { body, query, headers, cookies, requestUrl, response } | |
| }); | |
| if (childDeleteError) { | |
| return { error: childDeleteError }; | |
| } | |
| } | |
| } else if (onDeleteStrategy === 'setNull') { | |
| for (const childRecord of childRecords) { | |
| const { error: childUpdateError } = await this.adminforth.updateResourceRecord({ | |
| resource: childRes, | |
| record: childRecord, | |
| adminUser, | |
| recordId: childRecord[childPkFieldName], | |
| updatedRecord: { [foreignResourceColumn.name]: null }, | |
| response, | |
| extra: { body, query, headers, cookies, requestUrl, response } | |
| }); | |
| if (childUpdateError) { | |
| return { error: childUpdateError }; | |
| } |
adminforth/modules/restApi.ts
Outdated
| const foreignResourceColumn = childRes.columns.find(c => c.foreignResource?.resourceId === resource.resourceId); | ||
| if (!foreignResourceColumn.foreignResource.onDelete) continue; | ||
| const onDeleteStrategy = foreignResourceColumn.foreignResource.onDelete; | ||
| const childRecords = await this.adminforth.resource(childRes.resourceId).list(Filters.EQ(foreignResourceColumn.name, body['primaryKey'])) | ||
| const childPkColumn = childRes.columns.find(col => col.primaryKey); | ||
| if (!childPkColumn) continue; | ||
| const childPkFieldName = childPkColumn.name; | ||
| if (onDeleteStrategy === 'cascade') { | ||
| for (const childRecord of childRecords) { | ||
| await this.adminforth.resource(childRes.resourceId).delete(childRecord[childPkFieldName]); | ||
| } | ||
| } else if (onDeleteStrategy === 'setNull') { | ||
| for (const childRecord of childRecords) { | ||
| await this.adminforth.resource(childRes.resourceId).update(childRecord[childPkFieldName], {[foreignResourceColumn.name]: null}); |
There was a problem hiding this comment.
At line 1487, childRes.columns.find(...) only returns the FIRST column in the child resource that has a foreignResource.resourceId matching the parent. If a child resource has multiple columns referencing the same parent resource (e.g., created_by and updated_by both pointing to users), only the first matching column will have its cascade/setNull strategy applied. The second FK column will be silently ignored.
Consider using childRes.columns.filter(...) to get all FK columns pointing to the parent resource, and then iterating over each one.
| const foreignResourceColumn = childRes.columns.find(c => c.foreignResource?.resourceId === resource.resourceId); | |
| if (!foreignResourceColumn.foreignResource.onDelete) continue; | |
| const onDeleteStrategy = foreignResourceColumn.foreignResource.onDelete; | |
| const childRecords = await this.adminforth.resource(childRes.resourceId).list(Filters.EQ(foreignResourceColumn.name, body['primaryKey'])) | |
| const childPkColumn = childRes.columns.find(col => col.primaryKey); | |
| if (!childPkColumn) continue; | |
| const childPkFieldName = childPkColumn.name; | |
| if (onDeleteStrategy === 'cascade') { | |
| for (const childRecord of childRecords) { | |
| await this.adminforth.resource(childRes.resourceId).delete(childRecord[childPkFieldName]); | |
| } | |
| } else if (onDeleteStrategy === 'setNull') { | |
| for (const childRecord of childRecords) { | |
| await this.adminforth.resource(childRes.resourceId).update(childRecord[childPkFieldName], {[foreignResourceColumn.name]: null}); | |
| const foreignResourceColumns = childRes.columns.filter(c => c.foreignResource?.resourceId === resource.resourceId); | |
| if (!foreignResourceColumns.length) continue; | |
| const childPkColumn = childRes.columns.find(col => col.primaryKey); | |
| if (!childPkColumn) continue; | |
| const childPkFieldName = childPkColumn.name; | |
| for (const foreignResourceColumn of foreignResourceColumns) { | |
| if (!foreignResourceColumn.foreignResource || !foreignResourceColumn.foreignResource.onDelete) { | |
| continue; | |
| } | |
| const onDeleteStrategy = foreignResourceColumn.foreignResource.onDelete; | |
| const childRecords = await this.adminforth.resource(childRes.resourceId).list( | |
| Filters.EQ(foreignResourceColumn.name, body['primaryKey']) | |
| ); | |
| if (onDeleteStrategy === 'cascade') { | |
| for (const childRecord of childRecords) { | |
| await this.adminforth.resource(childRes.resourceId).delete(childRecord[childPkFieldName]); | |
| } | |
| } else if (onDeleteStrategy === 'setNull') { | |
| for (const childRecord of childRecords) { | |
| await this.adminforth.resource(childRes.resourceId).update( | |
| childRecord[childPkFieldName], | |
| { [foreignResourceColumn.name]: null } | |
| ); | |
| } |
| const [fkResults] = await this.client.execute(` | ||
| SELECT | ||
| kcu.TABLE_NAME AS child_table, | ||
| kcu.COLUMN_NAME AS column_name, | ||
| rc.DELETE_RULE AS delete_rule | ||
| FROM information_schema.KEY_COLUMN_USAGE kcu | ||
| JOIN information_schema.REFERENTIAL_CONSTRAINTS rc | ||
| ON kcu.CONSTRAINT_NAME = rc.CONSTRAINT_NAME | ||
| AND kcu.CONSTRAINT_SCHEMA = rc.CONSTRAINT_SCHEMA | ||
| WHERE kcu.REFERENCED_TABLE_NAME = ? | ||
| AND kcu.TABLE_SCHEMA = DATABASE() | ||
| `, [resource.table]); | ||
|
|
||
| const fkMap: Record<string, { cascade: boolean; childTable: string }> = {}; | ||
| for (const fk of fkResults as any[]) { | ||
| fkMap[String(fk.column_name)] = { | ||
| cascade: String(fk.delete_rule).toUpperCase() === 'CASCADE', | ||
| childTable: fk.child_table | ||
| }; | ||
| if (fkMap[fk.column_name].cascade) { | ||
| afLogger.warn(`The database has ON DELETE CASCADE, which may conflict with adminForth cascade deletion and upload logic. Please remove it.`); | ||
| } |
There was a problem hiding this comment.
The MySQL query uses WHERE kcu.REFERENCED_TABLE_NAME = ? and selects kcu.COLUMN_NAME, which returns column names from CHILD tables that reference the current table (i.e., columns in other tables that point to this table as a parent). This is the opposite perspective from the SQLite implementation, which uses PRAGMA foreign_key_list(tableName) to return FK columns defined ON the current table (the child side).
The practical effect is that in MySQL, fkMap keys are column names from child tables rather than columns of the current table being discovered. For the warning logic this is functionally harmless since the warning fires as long as any cascade is found. However, it is semantically inconsistent with the SQLite approach and with the intent of discoverFields (which is supposed to characterize the fields of the current table). If fkMap is later used to set field properties (similar to SQLite's field.cascade = fkMap[row.name] || false), this inverted mapping would cause incorrect results.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 6 out of 6 changed files in this pull request and generated 2 comments.
Comments suppressed due to low confidence (2)
adminforth/modules/restApi.ts:1526
- The
deleteWithCascademethod (line 190) already callsthis.adminforth.deleteResourceRecord(...)for the top-level resource at the end, which performs the actual delete and fires before/after hooks. Then at lines 1521-1526,deleteWithCascadeis called first, and immediately after,deleteResourceRecordis called again for the same record. This results in the record being deleted twice (which will likely cause a "record not found" error or silent no-op on the second call) and before/after delete hooks being fired twice for the top-level record.
The second call to deleteResourceRecord at lines 1523-1526 should be removed; deleteWithCascade already handles deleting the record and firing its hooks.
await this.deleteWithCascade(resource, body.primaryKey, {body, adminUser, query, headers, cookies, requestUrl, response});
const { error: deleteError } = await this.adminforth.deleteResourceRecord({
resource, record, adminUser, recordId: body['primaryKey'], response,
extra: { body, query, headers, cookies, requestUrl, response }
});
adminforth/modules/configValidator.ts:301
- The bulk delete action manually invokes
beforeSavehooks (lines 263-279) andafterSavehooks (lines 288-301) around the call todeleteWithCascade. However,deleteWithCascadeinternally callsthis.adminforth.deleteResourceRecord(...)(restApi.ts line 190), which itself runs allbeforeSaveandafterSavehooks for the resource (index.ts lines 758 and 781). This causes all delete hooks to fire twice for each record deleted via the bulk action: once explicitly in the bulk action and once insidedeleteWithCascade/deleteResourceRecord.
The manual hook invocations in lines 263-279 and 288-301 should be removed from the bulk delete action, as deleteWithCascade (via deleteResourceRecord) already handles them.
await Promise.all(
(res.hooks.delete.beforeSave).map(
async (hook) => {
const resp = await hook({
recordId: recordId,
resource: res as AdminForthResource,
record,
adminUser,
response,
adminforth: this.adminforth
});
if (!error && resp.error) {
error = resp.error;
}
}
)
)
if (error) {
return;
}
const restApi = new AdminForthRestAPI (this.adminforth)
restApi.deleteWithCascade(res as AdminForthResource, recordId, { adminUser, response, body: undefined, query: undefined, headers: undefined, cookies: undefined, requestUrl: undefined});
await Promise.all(
(res.hooks.delete.afterSave).map(
async (hook) => {
await hook({
resource: res as AdminForthResource,
record,
adminUser,
recordId: recordId,
response,
adminforth: this.adminforth,
});
}
)
)
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 7 out of 7 changed files in this pull request and generated 3 comments.
Comments suppressed due to low confidence (2)
adminforth/modules/configValidator.ts:301
- In the bulk delete action handler (
configValidator.ts), the code manually runsbeforeSavehooks (lines 263-279), then callsdeleteWithCascade(line 286) which internally callsdeleteResourceRecord— which itself runsbeforeSavehooks, deletes the record, and then runsafterSavehooks. Then the code manually runsafterSavehooks again (lines 288-301). This results in:
beforeSavehooks executing twice for each deleted recordafterSavehooks executing twice for each deleted record
The manual hook execution blocks (lines 263-279 and 288-301) should be removed, since deleteWithCascade (via deleteResourceRecord) already handles the complete lifecycle including hooks and deletion.
await Promise.all(
(res.hooks.delete.beforeSave).map(
async (hook) => {
const resp = await hook({
recordId: recordId,
resource: res as AdminForthResource,
record,
adminUser,
response,
adminforth: this.adminforth
});
if (!error && resp.error) {
error = resp.error;
}
}
)
)
if (error) {
return;
}
const restApi = new AdminForthRestAPI (this.adminforth)
await restApi.deleteWithCascade(res as AdminForthResource, recordId, { adminUser, response});
await Promise.all(
(res.hooks.delete.afterSave).map(
async (hook) => {
await hook({
resource: res as AdminForthResource,
record,
adminUser,
recordId: recordId,
response,
adminforth: this.adminforth,
});
}
)
)
adminforth/modules/restApi.ts:1525
- The
delete_recordendpoint callsdeleteWithCascade(line 1520) and then immediately callsdeleteResourceRecordagain (lines 1522-1525) on the same parent record.deleteWithCascadealready callsdeleteResourceRecordinternally (restApi.ts line 189), which runsbeforeSavehooks, deletes the record from the database, and runsafterSavehooks. The second call at line 1522 will:
- Run
beforeSavehooks a second time - Attempt to delete a record that has already been deleted from the database
- Run
afterSavehooks a second time
The second deleteResourceRecord call should be removed, as deleteWithCascade already handles the deletion of the parent record.
await this.deleteWithCascade(resource, body.primaryKey, {adminUser, response});
const { error: deleteError } = await this.adminforth.deleteResourceRecord({
resource, record, adminUser, recordId: body['primaryKey'], response,
extra: { body, query, headers, cookies, requestUrl, response }
});
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| const fkStmt = this.client.prepare(`PRAGMA foreign_key_list(${tableName})`); | ||
| const fkRows = await fkStmt.all(); | ||
| const fkMap: { [colName: string]: boolean } = {}; | ||
| fkRows.forEach(fk => { | ||
| fkMap[fk.from] = fk.on_delete?.toUpperCase() === 'CASCADE'; |
There was a problem hiding this comment.
SQLite's PRAGMA foreign_key_list(tableName) returns the FK constraints defined in tableName — i.e., where tableName is the child/dependent table. So fkMap[fk.from] maps columns in the current table that reference other (parent) tables, and checks if they have ON DELETE CASCADE.
However, the warning aims to detect conflicts where a child table targeting the current (parent) resource already has a DB-level ON DELETE CASCADE — which would double-delete when adminForth's cascade deletion also runs. To detect that conflict correctly, you need to check tables that reference the current table as their parent (the reverse direction).
In contrast, the PostgreSQL and MySQL queries correctly filter by the current tableName as the referenced/parent table and find child tables that cascade-delete on the current table's row deletion.
The SQLite check should use a different approach to detect child tables that reference the current table with ON DELETE CASCADE (e.g., check PRAGMA foreign_key_list on all other discovered tables and look for references to the current table).
| const fkMap: Record<string, { cascade: boolean; targetTable: string }> = {}; | ||
|
|
||
| for (const row of res.rows) { | ||
| fkMap[row.column_name.toLowerCase()] = { | ||
| cascade: row.confdeltype === 'c', | ||
| targetTable: row.parent_table, | ||
| }; | ||
| } | ||
| return fkMap; |
There was a problem hiding this comment.
The fkMap returned from getPgFkCascadeMap is keyed by column_name from child tables (not the current table being discovered). When multiple child tables reference the current table using differently-named columns (e.g., child_a.user_id and child_b.user_id), the last write would overwrite the earlier entry. However, since the map is only used to check if any cascade exists (line 112), collisions don't affect correctness of the warning. But the map itself contains misleading data — it represents child-table FK columns, not the current table's own columns — and the variable naming (targetTable refers to the parent, not the child) adds to the confusion. The entire fkMap structure is unnecessary; since only the cascade boolean is needed for the warning, a simpler Set<string> or direct boolean flag would be cleaner.
adminforth/modules/restApi.ts
Outdated
| async deleteWithCascade(resource: AdminForthResource, primaryKey: string, context: {adminUser: any, response: any}) { | ||
| const { adminUser, response } = context; | ||
|
|
||
| const record = await this.adminforth.connectors[resource.dataSource].getRecordByPrimaryKey(resource, primaryKey); | ||
|
|
||
| if (!record) return; | ||
|
|
||
| const childResources = this.adminforth.config.resources.filter(r =>r.columns.some(c => c.foreignResource?.resourceId === resource.resourceId)); | ||
|
|
||
| for (const childRes of childResources) { | ||
| const foreignColumn = childRes.columns.find(c => c.foreignResource?.resourceId === resource.resourceId); | ||
|
|
||
| if (!foreignColumn?.foreignResource?.onDelete) continue; | ||
|
|
||
| const strategy = foreignColumn.foreignResource.onDelete; | ||
|
|
||
| const childRecords = await this.adminforth.resource(childRes.resourceId).list(Filters.EQ(foreignColumn.name, primaryKey)); | ||
|
|
||
| const childPk = childRes.columns.find(c => c.primaryKey)?.name; | ||
| if (!childPk) continue; | ||
|
|
||
| if (strategy === 'cascade') { | ||
| for (const childRecord of childRecords) { | ||
| await this.deleteWithCascade(childRes, childRecord[childPk], context); | ||
| } | ||
| } | ||
|
|
||
| if (strategy === 'setNull') { | ||
| for (const childRecord of childRecords) { | ||
| await this.adminforth.resource(childRes.resourceId).update(childRecord[childPk], {[foreignColumn.name]: null}); | ||
| } | ||
| } | ||
| } | ||
|
|
||
| await this.adminforth.deleteResourceRecord({resource, record, adminUser, recordId: primaryKey, response}); |
There was a problem hiding this comment.
deleteWithCascade ignores the return value of deleteResourceRecord (line 189), which returns { error: string | null }. If a beforeSave hook returns an error or the DB delete fails, the error is silently discarded. Callers of deleteWithCascade (including the delete_record endpoint and the bulk delete action) have no way to know whether deletion actually succeeded.
The method signature should return a result indicating success/failure (e.g., Promise<{ error: string | null }>), and callers should check the returned error and halt the operation if one is present. In the cascade case (line 178), if a child deletion fails, the parent should not be deleted.
| async deleteWithCascade(resource: AdminForthResource, primaryKey: string, context: {adminUser: any, response: any}) { | |
| const { adminUser, response } = context; | |
| const record = await this.adminforth.connectors[resource.dataSource].getRecordByPrimaryKey(resource, primaryKey); | |
| if (!record) return; | |
| const childResources = this.adminforth.config.resources.filter(r =>r.columns.some(c => c.foreignResource?.resourceId === resource.resourceId)); | |
| for (const childRes of childResources) { | |
| const foreignColumn = childRes.columns.find(c => c.foreignResource?.resourceId === resource.resourceId); | |
| if (!foreignColumn?.foreignResource?.onDelete) continue; | |
| const strategy = foreignColumn.foreignResource.onDelete; | |
| const childRecords = await this.adminforth.resource(childRes.resourceId).list(Filters.EQ(foreignColumn.name, primaryKey)); | |
| const childPk = childRes.columns.find(c => c.primaryKey)?.name; | |
| if (!childPk) continue; | |
| if (strategy === 'cascade') { | |
| for (const childRecord of childRecords) { | |
| await this.deleteWithCascade(childRes, childRecord[childPk], context); | |
| } | |
| } | |
| if (strategy === 'setNull') { | |
| for (const childRecord of childRecords) { | |
| await this.adminforth.resource(childRes.resourceId).update(childRecord[childPk], {[foreignColumn.name]: null}); | |
| } | |
| } | |
| } | |
| await this.adminforth.deleteResourceRecord({resource, record, adminUser, recordId: primaryKey, response}); | |
| async deleteWithCascade( | |
| resource: AdminForthResource, | |
| primaryKey: string, | |
| context: { adminUser: any; response: any } | |
| ): Promise<{ error: string | null }> { | |
| const { adminUser, response } = context; | |
| const record = await this.adminforth.connectors[resource.dataSource].getRecordByPrimaryKey(resource, primaryKey); | |
| // If there is nothing to delete, treat this as a successful no-op. | |
| if (!record) { | |
| return { error: null }; | |
| } | |
| const childResources = this.adminforth.config.resources.filter(r => | |
| r.columns.some(c => c.foreignResource?.resourceId === resource.resourceId) | |
| ); | |
| for (const childRes of childResources) { | |
| const foreignColumn = childRes.columns.find( | |
| c => c.foreignResource?.resourceId === resource.resourceId | |
| ); | |
| if (!foreignColumn?.foreignResource?.onDelete) continue; | |
| const strategy = foreignColumn.foreignResource.onDelete; | |
| const childRecords = await this.adminforth | |
| .resource(childRes.resourceId) | |
| .list(Filters.EQ(foreignColumn.name, primaryKey)); | |
| const childPk = childRes.columns.find(c => c.primaryKey)?.name; | |
| if (!childPk) continue; | |
| if (strategy === 'cascade') { | |
| for (const childRecord of childRecords) { | |
| const childResult = await this.deleteWithCascade( | |
| childRes, | |
| childRecord[childPk], | |
| context | |
| ); | |
| if (childResult?.error) { | |
| // If a child deletion fails, do not delete the parent. | |
| return childResult; | |
| } | |
| } | |
| } | |
| if (strategy === 'setNull') { | |
| for (const childRecord of childRecords) { | |
| await this.adminforth | |
| .resource(childRes.resourceId) | |
| .update(childRecord[childPk], { [foreignColumn.name]: null }); | |
| } | |
| } | |
| } | |
| const deleteResult = await this.adminforth.deleteResourceRecord({ | |
| resource, | |
| record, | |
| adminUser, | |
| recordId: primaryKey, | |
| response, | |
| }); | |
| return deleteResult; |
adminforth/modules/restApi.ts
Outdated
| const record = await this.adminforth.connectors[resource.dataSource].getRecordByPrimaryKey(resource, primaryKey); | ||
|
|
||
| if (!record) return; | ||
| if (!record){ return {error: null};} |
There was a problem hiding this comment.
Maybe better is to tell something to user?
Or do it like was just return
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 7 out of 7 changed files in this pull request and generated 4 comments.
Comments suppressed due to low confidence (1)
adminforth/modules/configValidator.ts:301
- In the bulk delete action,
beforeSavehooks are manually executed at lines 263–279, followed bydeleteWithCascadeat line 286, followed by manualafterSavehook execution at lines 288–301.
However, deleteWithCascade internally calls deleteResourceRecord (see restApi.ts line 191), which itself runs all beforeSave and afterSave delete hooks for the parent resource. This causes every delete hook defined on the parent resource to be invoked twice per bulk-deleted record.
The fix should either:
- Remove the manual hook calls (lines 263–279 and 288–301) since
deleteWithCascadealready handles them, OR - Refactor
deleteWithCascadeto only cascade child operations (not delete the parent), and rely on the caller to invokedeleteResourceRecordfor the parent.
await Promise.all(
(res.hooks.delete.beforeSave).map(
async (hook) => {
const resp = await hook({
recordId: recordId,
resource: res as AdminForthResource,
record,
adminUser,
response,
adminforth: this.adminforth
});
if (!error && resp.error) {
error = resp.error;
}
}
)
)
if (error) {
return;
}
const restApi = new AdminForthRestAPI (this.adminforth)
await restApi.deleteWithCascade(res as AdminForthResource, recordId, { adminUser, response});
await Promise.all(
(res.hooks.delete.afterSave).map(
async (hook) => {
await hook({
resource: res as AdminForthResource,
record,
adminUser,
recordId: recordId,
response,
adminforth: this.adminforth,
});
}
)
)
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| const fkMap = await this.getPgFkCascadeMap(tableName); | ||
| const hasCascade = Object.values(fkMap).some(fk => fk.cascade); | ||
| if (hasCascade) { | ||
| afLogger.warn(`The database has ON DELETE CASCADE, which may conflict with adminForth cascade deletion and upload logic. Please remove it.`); | ||
| } |
There was a problem hiding this comment.
Similar to the MySQL connector, the Postgres fkMap built in getPgFkCascadeMap (lines 97–105) is used only to emit the hasCascade warning (lines 112–115) and is never used to set a field.cascade property on individual discovered fields. The SQLite connector sets field.cascade per field, but Postgres does not, leading to an inconsistency across connectors.
| } | ||
|
|
||
| await this.deleteWithCascade(resource, body.primaryKey, {adminUser, response}); | ||
|
|
||
| const { error: deleteError } = await this.adminforth.deleteResourceRecord({ | ||
| resource, record, adminUser, recordId: body['primaryKey'], response, |
There was a problem hiding this comment.
The deleteWithCascade method (line 191) already calls this.adminforth.deleteResourceRecord(...) internally, which performs the actual DB deletion and runs both beforeSave and afterSave delete hooks. Then, immediately after calling deleteWithCascade at line 1523, deleteResourceRecord is called again at lines 1525–1528 for the same parent record.
This causes two problems:
- The parent record is deleted twice — the second deletion either silently no-ops or throws a "record not found" error.
- Both
beforeSaveandafterSavedelete hooks fire twice for the parent resource.
The fix is to remove the redundant deleteResourceRecord call at lines 1525–1528, since the deletion and hook execution is already handled inside deleteWithCascade.
| const fkMap: Record<string, { cascade: boolean; childTable: string }> = {}; | ||
| for (const fk of fkResults as any[]) { | ||
| fkMap[String(fk.column_name)] = { | ||
| cascade: String(fk.delete_rule).toUpperCase() === 'CASCADE', | ||
| childTable: fk.child_table | ||
| }; | ||
| if (fkMap[fk.column_name].cascade) { | ||
| afLogger.warn(`The database has ON DELETE CASCADE, which may conflict with adminForth cascade deletion and upload logic. Please remove it.`); | ||
| } | ||
| } |
There was a problem hiding this comment.
The MySQL fkMap built at lines 92–101 is keyed on child-table column names (columns from other tables that reference this table), yet it is never used to annotate any field property in the current table's fieldTypes. Only the warning is emitted when cascade is true.
By contrast, the SQLite connector (sqlite.ts line 96) sets field.cascade = fkMap[row.name] || false on each discovered field. The Postgres connector has the same inconsistency — the fkMap from getPgFkCascadeMap is used only for the warning, never to set field.cascade.
If field.cascade is not needed for Postgres and MySQL, the SQLite connector should also drop field.cascade and the logic should be consistent. If field.cascade is needed, the MySQL and Postgres connectors need equivalent per-field annotation.
…oint